SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.1 KB · 139 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { notFound } from 'next/navigation';3import { Suspense } from 'react';4import { Tabs } from '@/components/ui/tabs';5import { Skeleton } from '@/components/ui/primitives';6import { AssetHeader } from '@/components/asset/asset-header';7import { ASSET_TABS, AnalysisTab, AuctionsTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs';8import { countAssetLots } from '@/lib/queries/market-lists';9import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets';10import { getCurrentUser } from '@/lib/auth/session';11import { catName, categoryPath } from '@/lib/taxonomy';12import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';13import { fmtMoney } from '@/lib/format';1415export const revalidate = 120;1617const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';1819export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {20  const { slug } = await params;21  const a = await getAssetBySlug(slug);22  if (!a) return { title: 'Asset not found' };23  const guide = a.rivUsd === null && a.latestSaleUsd === null ? await getLatestGuidePrice(a.id) : null;24  const price = a.rivUsd !== null ? ` · RIV ${fmtMoney(a.rivUsd)}` : a.latestSaleUsd !== null ? ` · last sale ${fmtMoney(a.latestSaleUsd)}` : guide ? ` · guide price ${fmtMoney(guide.priceUsd)} (${guide.sourceName})` : '';25  const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`;26  return {27    title: a.title,28    description,29    alternates: { canonical: `${SITE}/asset/${a.slug}` },30    openGraph: { title: a.title, description, url: `${SITE}/asset/${a.slug}`, images: [{ url: `/asset/${a.slug}/opengraph-image`, width: 1200, height: 630 }] },31    twitter: { card: 'summary_large_image', title: a.title, description },32  };33}3435export default async function AssetPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {36  const { slug } = await params;37  const sp = await searchParams;38  const found = await getAssetBySlug(slug);39  if (!found) notFound();40  const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview');41  const [variants, live, imgs, user, popReports, lotCount] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null), getPopulation(found.id), countAssetLots(found.id)]);42  // latest published population total across graders (for the rarity explainer); null when no report exists43  const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null;44  const watched = await isWatchedBy(user?.id ?? null, found.id);45  // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables.46  const asset = {47    ...found,48    salesCount: Math.max(found.salesCount, live.sales),49    sales30d: Math.max(found.sales30d, live.sales30d),50    activeListings: Math.max(found.activeListings, live.listings),51    sourcesCount: Math.max(found.sourcesCount, live.sources),52    latestSaleUsd: found.latestSaleUsd ?? live.latestSaleUsd,53    latestSaleAt: found.latestSaleAt ?? live.latestSaleAt,54    minAskUsd: found.minAskUsd ?? live.minAskUsd,55  };56  const vParam = sp1(sp, 'v');57  const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null;58  const page = spInt(sp, 'page');59  const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`;60  const [listingsForLd, guide] = await Promise.all([getAssetListings(asset.id, { limit: 20 }), (variant ? variant.rivUsd : asset.rivUsd) === null ? getLatestGuidePrice(asset.id, variant?.id ?? null) : Promise.resolve(null)]);61  const crumbs = categoryPath(asset.categorySlug);62  const seen = new Set<string>();63  const images = [asset.heroImageUrl ? { url: asset.heroImageUrl, caption: null } : null, ...imgs.map((im) => ({ url: im.url, caption: im.attribution ?? im.sourceId ?? null }))]64    .filter((im): im is { url: string; caption: string | null } => Boolean(im))65    .filter((im) => (seen.has(im.url) ? false : (seen.add(im.url), true)));6667  const jsonLd = [68    {69      '@context': 'https://schema.org',70      '@type': 'Product',71      name: asset.title,72      image: images.slice(0, 5).map((i) => i.url),73      description: asset.description ?? `${asset.title} — ${catName(asset.categorySlug)} collectible tracked by RareIndex.`,74      brand: asset.brand ? { '@type': 'Brand', name: asset.brand } : undefined,75      category: crumbs.map((c) => c.name).join(' > '),76      productID: asset.id,77      sku: asset.identifiers.sku ?? asset.identifiers.upc ?? undefined,78      url: `${SITE}/asset/${asset.slug}`,79      ...(listingsForLd.length && listingsForLd.some((l) => l.priceUsd !== null)80        ? {81            offers: {82              '@type': 'AggregateOffer',83              priceCurrency: 'USD',84              lowPrice: Math.min(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)),85              highPrice: Math.max(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)),86              offerCount: listingsForLd.length,87              offers: listingsForLd.slice(0, 5).map((l) => ({ '@type': 'Offer', price: l.priceUsd, priceCurrency: 'USD', url: l.sourceUrl, availability: 'https://schema.org/InStock', seller: l.seller ? { '@type': 'Organization', name: l.seller } : undefined })),88            },89          }90        : {}),91    },92    {93      '@context': 'https://schema.org',94      '@type': 'BreadcrumbList',95      itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Markets', item: `${SITE}/markets` }, ...crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 2, name: c.name, item: `${SITE}/markets/${c.slug}` })), { '@type': 'ListItem', position: crumbs.length + 2, name: asset.title, item: `${SITE}/asset/${asset.slug}` }],96    },97  ];9899  return (100    <div className="pb-16 md:pb-0">101      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />102      <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} images={images} watched={watched} population={population} />103      <div className="sticky top-[var(--ri-header-h,56px)] z-30 -mx-4 bg-bg/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-bg/80 sm:static sm:mx-0 sm:bg-transparent sm:px-0 sm:backdrop-blur-none">104        <Tabs105          className="mt-6 mb-4"106          active={tab}107          tabs={[108            { id: 'overview', label: 'Overview', href: href('overview') },109            { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },110            { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') },111            { id: 'auctions', label: 'Auctions', count: lotCount || null, href: href('auctions') },112            { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },113            { id: 'population', label: 'Population', href: href('population') },114            { id: 'images', label: 'Images', count: images.length || null, href: href('images') },115            { id: 'history', label: 'History', href: href('history') },116            { id: 'comparables', label: 'Comparables', href: href('comparables') },117            { id: 'analysis', label: 'AI Analysis', href: href('analysis') },118            { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') },119          ]}120        />121      </div>122      <Suspense key={`${tab}-${variant?.id ?? ''}-${page}`} fallback={<Skeleton className="h-96" />}>123        {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null}124        {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null}125        {tab === 'listings' ? <ListingsTab asset={asset} variant={variant} /> : null}126        {tab === 'auctions' ? <AuctionsTab asset={asset} /> : null}127        {tab === 'grades' ? <GradesTab asset={asset} /> : null}128        {tab === 'population' ? <PopulationTab asset={asset} /> : null}129        {tab === 'images' ? <ImagesTab asset={asset} /> : null}130        {tab === 'history' ? <HistoryTab asset={asset} variant={variant} /> : null}131        {tab === 'comparables' ? <ComparablesTab asset={asset} /> : null}132        {tab === 'analysis' ? <AnalysisTab asset={asset} /> : null}133        {tab === 'sources' ? <SourcesTab asset={asset} /> : null}134      </Suspense>135      <p className="mt-6 text-[11px] leading-relaxed text-subtle">Valuations are estimates with a stated confidence; listing prices are not confirmed transactions; past performance does not guarantee future results. RareIndex does not authenticate items. Data attributed to its source; marks belong to their owners.</p>136    </div>137  );138}139